import pandas as pd
import numpy as np
import seaborn as sn
import matplotlib.pyplot as plt
from scipy import stats as st
from datetime import datetime, timedelta
import warnings
warnings.filterwarnings("ignore")
custom_params = {"axes.spines.right": False, "axes.spines.top": False}
sn.set(style="ticks", rc=custom_params)
pd.set_option('display.max_columns', None)
pd.set_option('display.max_colwidth', None)
Анализ резултатов рекламной кампании Procrastinate Pro+:
Наша компания занимается разработкой развлекательного приложения Procrastinate Pro+. За последние несколько месяцев было вложено большое кол-во средств на его продвижение в различных каналах. Результаты рекламной кампании неутешительные, фирма терпит убытки. В нашем распоряжении есть база данных о пользователях приложениях, их активности и покупках внутри него, а также рекламных затратах фирмы за период с мая по октябрь 2019 года.
Наша задача выяснить причины маркетинговых неудач и выяснить:
Ответим на вопросы:
План работы:
1 этап. Общая информация о датасете.
2 этап. Предобработка.
3 этап. Функции для анализа.
4 этап. Исследовательский анализ.
5 этап. Маркетинговые показатели.
6 этап. Оценка окупаемости рекламы.
7 этап. Итоги.
try:
visits = pd.read_csv('H://Jupiter/visits_info_short.csv')
except:
visits = pd.read_csv('https://code.s3.yandex.net/datasets/visits_info_short.csv')
try:
purchases = pd.read_csv('H://Jupiter/orders_info_short.csv')
except:
purchases = pd.read_csv('https://code.s3.yandex.net/datasets/orders_info_short.csv')
try:
costs = pd.read_csv('H://Jupiter/costs_info_short.csv')
except:
costs = pd.read_csv('https://code.s3.yandex.net/datasets/costs_info_short.csv')
visits.head()
| User Id | Region | Device | Channel | Session Start | Session End | |
|---|---|---|---|---|---|---|
| 0 | 981449118918 | United States | iPhone | organic | 2019-05-01 02:36:01 | 2019-05-01 02:45:01 |
| 1 | 278965908054 | United States | iPhone | organic | 2019-05-01 04:46:31 | 2019-05-01 04:47:35 |
| 2 | 590706206550 | United States | Mac | organic | 2019-05-01 14:09:25 | 2019-05-01 15:32:08 |
| 3 | 326433527971 | United States | Android | TipTop | 2019-05-01 00:29:59 | 2019-05-01 00:54:25 |
| 4 | 349773784594 | United States | Mac | organic | 2019-05-01 03:33:35 | 2019-05-01 03:57:40 |
visits.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 309901 entries, 0 to 309900 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 User Id 309901 non-null int64 1 Region 309901 non-null object 2 Device 309901 non-null object 3 Channel 309901 non-null object 4 Session Start 309901 non-null object 5 Session End 309901 non-null object dtypes: int64(1), object(5) memory usage: 14.2+ MB
purchases.head()
| User Id | Event Dt | Revenue | |
|---|---|---|---|
| 0 | 188246423999 | 2019-05-01 23:09:52 | 4.99 |
| 1 | 174361394180 | 2019-05-01 12:24:04 | 4.99 |
| 2 | 529610067795 | 2019-05-01 11:34:04 | 4.99 |
| 3 | 319939546352 | 2019-05-01 15:34:40 | 4.99 |
| 4 | 366000285810 | 2019-05-01 13:59:51 | 4.99 |
purchases.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 40212 entries, 0 to 40211 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 User Id 40212 non-null int64 1 Event Dt 40212 non-null object 2 Revenue 40212 non-null float64 dtypes: float64(1), int64(1), object(1) memory usage: 942.6+ KB
costs.head()
| dt | Channel | costs | |
|---|---|---|---|
| 0 | 2019-05-01 | FaceBoom | 113.3 |
| 1 | 2019-05-02 | FaceBoom | 78.1 |
| 2 | 2019-05-03 | FaceBoom | 85.8 |
| 3 | 2019-05-04 | FaceBoom | 136.4 |
| 4 | 2019-05-05 | FaceBoom | 122.1 |
costs.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 1800 entries, 0 to 1799 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 dt 1800 non-null object 1 Channel 1800 non-null object 2 costs 1800 non-null float64 dtypes: float64(1), object(2) memory usage: 42.3+ KB
Из базовой информации по датасетам видно, что пропусков в них нет. Проверять их на явные дубликаты смысла нет, т.к. по характеру данных в базе будет много повторяющихся значений и сумм значений. Итого на этапе предобработки нам нужно:
Переименуем столбцы:
Для оптимизации напишем функцию и применим ко всем трём датасетам:
def temp(data):
data.columns = data.columns.str.lower()
for x in data.columns:
if ' ' in x:
data = data.rename(columns = {x:x.replace(' ', '_')})
return data
visits, purchases, costs = [temp(dataset) for dataset in [visits, purchases, costs]]
visits.sample()
| user_id | region | device | channel | session_start | session_end | |
|---|---|---|---|---|---|---|
| 197584 | 483275927401 | United States | iPhone | organic | 2019-10-22 02:54:28 | 2019-10-22 02:56:28 |
Во всех датасетах приведём колонки с временем к соответствующему типу данных:
visits['session_start'] = pd.to_datetime(visits['session_start'])
visits['session_end'] = pd.to_datetime(visits['session_end'])
purchases['event_dt'] = pd.to_datetime(purchases['event_dt'])
costs['dt'] = pd.to_datetime(costs['dt']).dt.date
visits.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 309901 entries, 0 to 309900 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 309901 non-null int64 1 region 309901 non-null object 2 device 309901 non-null object 3 channel 309901 non-null object 4 session_start 309901 non-null datetime64[ns] 5 session_end 309901 non-null datetime64[ns] dtypes: datetime64[ns](2), int64(1), object(3) memory usage: 14.2+ MB
costs.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 1800 entries, 0 to 1799 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 dt 1800 non-null object 1 channel 1800 non-null object 2 costs 1800 non-null float64 dtypes: float64(1), object(2) memory usage: 42.3+ KB
purchases.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 40212 entries, 0 to 40211 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 40212 non-null int64 1 event_dt 40212 non-null datetime64[ns] 2 revenue 40212 non-null float64 dtypes: datetime64[ns](1), float64(1), int64(1) memory usage: 942.6 KB
Проверим датасеты на наличие пропусков:
for dataset in [visits, purchases, costs]:
print(f'Кол-во пропусков: \n{dataset.isna().sum()}')
print()
Кол-во пропусков: user_id 0 region 0 device 0 channel 0 session_start 0 session_end 0 dtype: int64 Кол-во пропусков: user_id 0 event_dt 0 revenue 0 dtype: int64 Кол-во пропусков: dt 0 channel 0 costs 0 dtype: int64
Пропуски в датасетах отсутствуют.
Проверим датасеты на наличие дубликатов:
for dataset in [visits, purchases, costs]:
print(f'Кол-во дубликатов в датасете: {dataset.duplicated().sum()}')
Кол-во дубликатов в датасете: 0 Кол-во дубликатов в датасете: 0 Кол-во дубликатов в датасете: 0
Явных дубликатов не обнаружено.
Проверим колонки со строковыми значениями на наличие неявных дубликатов:
display(visits['channel'].unique())
display(visits['device'].unique())
display(visits['region'].unique())
display(costs['channel'].unique())
array(['organic', 'TipTop', 'RocketSuperAds', 'YRabbit', 'FaceBoom',
'MediaTornado', 'AdNonSense', 'LeapBob', 'WahooNetBanner',
'OppleCreativeMedia', 'lambdaMediaAds'], dtype=object)
array(['iPhone', 'Mac', 'Android', 'PC'], dtype=object)
array(['United States', 'UK', 'France', 'Germany'], dtype=object)
array(['FaceBoom', 'MediaTornado', 'RocketSuperAds', 'TipTop', 'YRabbit',
'AdNonSense', 'LeapBob', 'OppleCreativeMedia', 'WahooNetBanner',
'lambdaMediaAds'], dtype=object)
Ошибочных / задвоенных строковых значений не обнаружено.
Вывод
Предобработка завершена:
На следующем этапе подготовим необходимые функции для анализа базы.
def get_profiles(sessions, orders, ad_costs):
# находим параметры первых посещений
profiles = (
sessions.sort_values(by=['user_id', 'session_start'])
.groupby('user_id')
.agg(
{
'session_start': 'first',
'channel': 'first',
'device': 'first',
'region': 'first',
}
)
.rename(columns={'session_start': 'first_ts'})
.reset_index()
)
# для когортного анализа определяем дату первого посещения
# и первый день месяца, в который это посещение произошло
profiles['dt'] = profiles['first_ts'].dt.date
profiles['month'] = profiles['first_ts'].astype('datetime64[M]')
# добавляем признак платящих пользователей
profiles['payer'] = profiles['user_id'].isin(orders['user_id'].unique())
# считаем количество уникальных пользователей
# с одинаковыми источником и датой привлечения
new_users = (
profiles.groupby(['dt', 'channel'])
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'unique_users'})
.reset_index()
)
# объединяем траты на рекламу и число привлечённых пользователей
ad_costs = ad_costs.merge(new_users, on=['dt', 'channel'], how='left')
# делим рекламные расходы на число привлечённых пользователей
ad_costs['acquisition_cost'] = ad_costs['costs'] / ad_costs['unique_users']
# добавляем стоимость привлечения в профили
profiles = profiles.merge(
ad_costs[['dt', 'channel', 'acquisition_cost']],
on=['dt', 'channel'],
how='left',
)
# стоимость привлечения органических пользователей равна нулю
profiles['acquisition_cost'] = profiles['acquisition_cost'].fillna(0)
return profiles
def get_retention(
profiles,
sessions,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
):
# добавляем столбец payer в передаваемый dimensions список
dimensions = ['payer'] + dimensions
# исключаем пользователей, не «доживших» до горизонта анализа
last_suitable_acquisition_date = observation_date
if not ignore_horizon:
last_suitable_acquisition_date = observation_date - timedelta(
days=horizon_days - 1
)
result_raw = profiles.query('dt <= @last_suitable_acquisition_date')
# собираем «сырые» данные для расчёта удержания
result_raw = result_raw.merge(
sessions[['user_id', 'session_start']], on='user_id', how='left'
)
result_raw['lifetime'] = (
result_raw['session_start'] - result_raw['first_ts']
).dt.days
# функция для группировки таблицы по желаемым признакам
def group_by_dimensions(df, dims, horizon_days):
result = df.pivot_table(
index=dims, columns='lifetime', values='user_id', aggfunc='nunique'
)
cohort_sizes = (
df.groupby(dims)
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'})
)
result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
result = result.div(result['cohort_size'], axis=0)
result = result[['cohort_size'] + list(range(horizon_days))]
result['cohort_size'] = cohort_sizes
return result
# получаем таблицу удержания
result_grouped = group_by_dimensions(result_raw, dimensions, horizon_days)
# получаем таблицу динамики удержания
result_in_time = group_by_dimensions(
result_raw, dimensions + ['dt'], horizon_days
)
# возвращаем обе таблицы и сырые данные
return result_raw, result_grouped, result_in_time
def get_conversion(
profiles,
purchases,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
):
# исключаем пользователей, не «доживших» до горизонта анализа
last_suitable_acquisition_date = observation_date
if not ignore_horizon:
last_suitable_acquisition_date = observation_date - timedelta(
days=horizon_days - 1
)
result_raw = profiles.query('dt <= @last_suitable_acquisition_date')
# определяем дату и время первой покупки для каждого пользователя
first_purchases = (
purchases.sort_values(by=['user_id', 'event_dt'])
.groupby('user_id')
.agg({'event_dt': 'first'})
.reset_index()
)
# добавляем данные о покупках в профили
result_raw = result_raw.merge(
first_purchases[['user_id', 'event_dt']], on='user_id', how='left'
)
# рассчитываем лайфтайм для каждой покупки
result_raw['lifetime'] = (
result_raw['event_dt'] - result_raw['first_ts']
).dt.days
# группируем по cohort, если в dimensions ничего нет
if len(dimensions) == 0:
result_raw['cohort'] = 'All users'
dimensions = dimensions + ['cohort']
# функция для группировки таблицы по желаемым признакам
def group_by_dimensions(df, dims, horizon_days):
result = df.pivot_table(
index=dims, columns='lifetime', values='user_id', aggfunc='nunique'
)
result = result.fillna(0).cumsum(axis = 1)
cohort_sizes = (
df.groupby(dims)
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'})
)
result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
# делим каждую «ячейку» в строке на размер когорты
# и получаем conversion rate
result = result.div(result['cohort_size'], axis=0)
result = result[['cohort_size'] + list(range(horizon_days))]
result['cohort_size'] = cohort_sizes
return result
# получаем таблицу конверсии
result_grouped = group_by_dimensions(result_raw, dimensions, horizon_days)
# для таблицы динамики конверсии убираем 'cohort' из dimensions
if 'cohort' in dimensions:
dimensions = []
# получаем таблицу динамики конверсии
result_in_time = group_by_dimensions(
result_raw, dimensions + ['dt'], horizon_days
)
# возвращаем обе таблицы и сырые данные
return result_raw, result_grouped, result_in_time
def get_ltv(
profiles,
purchases,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
):
# исключаем пользователей, не «доживших» до горизонта анализа
last_suitable_acquisition_date = observation_date
if not ignore_horizon:
last_suitable_acquisition_date = observation_date - timedelta(
days=horizon_days - 1
)
result_raw = profiles.query('dt <= @last_suitable_acquisition_date')
# добавляем данные о покупках в профили
result_raw = result_raw.merge(
purchases[['user_id', 'event_dt', 'revenue']], on='user_id', how='left'
)
# рассчитываем лайфтайм пользователя для каждой покупки
result_raw['lifetime'] = (
result_raw['event_dt'] - result_raw['first_ts']
).dt.days
# группируем по cohort, если в dimensions ничего нет
if len(dimensions) == 0:
result_raw['cohort'] = 'All users'
dimensions = dimensions + ['cohort']
# функция группировки по желаемым признакам
def group_by_dimensions(df, dims, horizon_days):
# строим «треугольную» таблицу выручки
result = df.pivot_table(
index=dims, columns='lifetime', values='revenue', aggfunc='sum'
)
# находим сумму выручки с накоплением
result = result.fillna(0).cumsum(axis=1)
# вычисляем размеры когорт
cohort_sizes = (
df.groupby(dims)
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'})
)
# объединяем размеры когорт и таблицу выручки
result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
# считаем LTV: делим каждую «ячейку» в строке на размер когорты
result = result.div(result['cohort_size'], axis=0)
# исключаем все лайфтаймы, превышающие горизонт анализа
result = result[['cohort_size'] + list(range(horizon_days))]
# восстанавливаем размеры когорт
result['cohort_size'] = cohort_sizes
# собираем датафрейм с данными пользователей и значениями CAC,
# добавляя параметры из dimensions
cac = df[['user_id', 'acquisition_cost'] + dims].drop_duplicates()
# считаем средний CAC по параметрам из dimensions
cac = (
cac.groupby(dims)
.agg({'acquisition_cost': 'mean'})
.rename(columns={'acquisition_cost': 'cac'})
)
# считаем ROI: делим LTV на CAC
roi = result.div(cac['cac'], axis=0)
# удаляем строки с бесконечным ROI
roi = roi[~roi['cohort_size'].isin([np.inf])]
# восстанавливаем размеры когорт в таблице ROI
roi['cohort_size'] = cohort_sizes
# добавляем CAC в таблицу ROI
roi['cac'] = cac['cac']
# в финальной таблице оставляем размеры когорт, CAC
# и ROI в лайфтаймы, не превышающие горизонт анализа
roi = roi[['cohort_size', 'cac'] + list(range(horizon_days))]
# возвращаем таблицы LTV и ROI
return result, roi
# получаем таблицы LTV и ROI
result_grouped, roi_grouped = group_by_dimensions(
result_raw, dimensions, horizon_days
)
# для таблиц динамики убираем 'cohort' из dimensions
if 'cohort' in dimensions:
dimensions = []
# получаем таблицы динамики LTV и ROI
result_in_time, roi_in_time = group_by_dimensions(
result_raw, dimensions + ['dt'], horizon_days
)
return (
result_raw, # сырые данные
result_grouped, # таблица LTV
result_in_time, # таблица динамики LTV
roi_grouped, # таблица ROI
roi_in_time, # таблица динамики ROI
)
def filter_data(df, window):
# для каждого столбца применяем скользящее среднее
for column in df.columns.values:
df[column] = df[column].rolling(window).mean()
return df
def plot_retention(retention, retention_history, horizon, window=[]):
# задаём размер сетки для графиков
plt.figure(figsize=(15, 10))
# исключаем размеры когорт и удержание первого дня
retention = retention.drop(columns=['cohort_size', 0])
# в таблице динамики оставляем только нужный лайфтайм
retention_history = retention_history.drop(columns=['cohort_size'])[
[horizon - 1]
]
# если в индексах таблицы удержания только payer,
# добавляем второй признак — cohort
if retention.index.nlevels == 1:
retention['cohort'] = 'All users'
retention = retention.reset_index().set_index(['cohort', 'payer'])
# в таблице графиков — два столбца и две строки, четыре ячейки
# в первой строим кривые удержания платящих пользователей
ax1 = plt.subplot(2, 2, 1)
retention.query('payer == True').droplevel('payer').T.plot(
grid=True, ax=ax1
)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('Удержание платящих пользователей')
# во второй ячейке строим кривые удержания неплатящих
# вертикальная ось — от графика из первой ячейки
ax2 = plt.subplot(2, 2, 2, sharey=ax1)
retention.query('payer == False').droplevel('payer').T.plot(
grid=True, ax=ax2
)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('Удержание неплатящих пользователей')
# в третьей ячейке — динамика удержания платящих
ax3 = plt.subplot(2, 2, 3)
# получаем названия столбцов для сводной таблицы
columns = [
name
for name in retention_history.index.names
if name not in ['dt', 'payer']
]
# фильтруем данные и строим график
filtered_data = retention_history.query('payer == True').pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax3)
plt.xlabel('Дата привлечения')
plt.title(
'Динамика удержания платящих пользователей на {}-й день'.format(
horizon
)
)
# в чётвертой ячейке — динамика удержания неплатящих
ax4 = plt.subplot(2, 2, 4, sharey=ax3)
# фильтруем данные и строим график
filtered_data = retention_history.query('payer == False').pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax4)
plt.xlabel('Дата привлечения')
plt.title(
'Динамика удержания неплатящих пользователей на {}-й день'.format(
horizon
)
)
plt.tight_layout()
plt.show()
def plot_conversion(conversion, conversion_history, horizon, window=[]):
# задаём размер сетки для графиков
plt.figure(figsize=(15, 5))
# исключаем размеры когорт
conversion = conversion.drop(columns=['cohort_size'])
# в таблице динамики оставляем только нужный лайфтайм
conversion_history = conversion_history.drop(columns=['cohort_size'])[
[horizon - 1]
]
# первый график — кривые конверсии
ax1 = plt.subplot(1, 2, 1)
conversion.T.plot(grid=True, ax=ax1)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('Конверсия пользователей')
# второй график — динамика конверсии
ax2 = plt.subplot(1, 2, 2, sharey=ax1)
columns = [
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
name for name in conversion_history.index.names if name not in ['dt']
]
filtered_data = conversion_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax2)
plt.xlabel('Дата привлечения')
plt.title('Динамика конверсии пользователей на {}-й день'.format(horizon))
plt.tight_layout()
plt.show()
def plot_ltv_roi(ltv, ltv_history, roi, roi_history, horizon, window=7):
# задаём сетку отрисовки графиков
plt.figure(figsize=(20, 10))
# из таблицы ltv исключаем размеры когорт
ltv = ltv.drop(columns=['cohort_size'])
# в таблице динамики ltv оставляем только нужный лайфтайм
ltv_history = ltv_history.drop(columns=['cohort_size'])[[horizon - 1]]
# стоимость привлечения запишем в отдельный фрейм
cac_history = roi_history[['cac']]
# из таблицы roi исключаем размеры когорт и cac
roi = roi.drop(columns=['cohort_size', 'cac'])
# в таблице динамики roi оставляем только нужный лайфтайм
roi_history = roi_history.drop(columns=['cohort_size', 'cac'])[
[horizon - 1]
]
# первый график — кривые ltv
ax1 = plt.subplot(2, 3, 1)
ltv.T.plot(grid=True, ax=ax1)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('LTV')
# второй график — динамика ltv
ax2 = plt.subplot(2, 3, 2, sharey=ax1)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in ltv_history.index.names if name not in ['dt']]
filtered_data = ltv_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax2)
plt.xlabel('Дата привлечения')
plt.title('Динамика LTV пользователей на {}-й день'.format(horizon))
# третий график — динамика cac
ax3 = plt.subplot(2, 3, 3, sharey=ax1)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in cac_history.index.names if name not in ['dt']]
filtered_data = cac_history.pivot_table(
index='dt', columns=columns, values='cac', aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax3)
plt.xlabel('Дата привлечения')
plt.title('Динамика стоимости привлечения пользователей')
# четвёртый график — кривые roi
ax4 = plt.subplot(2, 3, 4)
roi.T.plot(grid=True, ax=ax4)
plt.axhline(y=1, color='red', linestyle='--', label='Уровень окупаемости')
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('ROI')
# пятый график — динамика roi
ax5 = plt.subplot(2, 3, 5, sharey=ax4)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in roi_history.index.names if name not in ['dt']]
filtered_data = roi_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax5)
plt.axhline(y=1, color='red', linestyle='--', label='Уровень окупаемости')
plt.xlabel('Дата привлечения')
plt.title('Динамика ROI пользователей на {}-й день'.format(horizon))
plt.tight_layout()
plt.show()
Функции готовы к использованию. Можем переходить к анализу базы данных.
Сформируем профили пользователей с помощью заготовленной нами функции:
profiles = get_profiles(visits, purchases, costs)
print(profiles.head(5))
profiles.count()
user_id first_ts channel device region \
0 599326 2019-05-07 20:58:57 FaceBoom Mac United States
1 4919697 2019-07-09 12:46:07 FaceBoom iPhone United States
2 6085896 2019-10-01 09:58:33 organic iPhone France
3 22593348 2019-08-22 21:35:48 AdNonSense PC Germany
4 31989216 2019-10-02 00:07:44 YRabbit iPhone United States
dt month payer acquisition_cost
0 2019-05-07 2019-05-01 True 1.088172
1 2019-07-09 2019-07-01 False 1.107237
2 2019-10-01 2019-10-01 False 0.000000
3 2019-08-22 2019-08-01 False 0.988235
4 2019-10-02 2019-10-01 False 0.230769
user_id 150008 first_ts 150008 channel 150008 device 150008 region 150008 dt 150008 month 150008 payer 150008 acquisition_cost 150008 dtype: int64
Узнаем самую раннюю и самую позднюю дату привлечения пользователя:
print('Самая ранняя дата:', profiles['first_ts'].min())
print('Самая поздняя дата:', profiles['first_ts'].max())
Самая ранняя дата: 2019-05-01 00:00:41 Самая поздняя дата: 2019-10-27 23:59:04
Таким образом, самый поздний новый пользователь появился в базе 27 октября 2019 года, самый ранний - 1 мая 2019 года.
Согласно тех. заданию нам требуется провести анализ на момент 01 ноября 2019 года с горизонтом анализа 14 дней. Т.е. самая поздняя дата, в которую должно попадать первое посещение пользователя - 17 ноября 2019 года.
Вывод: Имеющейся базы данных для выполнения пооставленной задачи достаточно.
Выясним, из каких стран пользователи приходят в приложение и на какую страну приходится больше всего платящих пользователей. Построим таблицу, отражающую количество пользователей и долю платящих из каждой страны.
profiles.groupby('region').agg(
{'user_id': 'count', 'payer': ['sum', 'mean']}
).sort_values(by=('payer', 'sum'), ascending=False).applymap(lambda x: '{:.2%}'.format(x) if isinstance(x, float) else x)
| user_id | payer | ||
|---|---|---|---|
| count | sum | mean | |
| region | |||
| United States | 100002 | 6902 | 6.90% |
| UK | 17575 | 700 | 3.98% |
| France | 17450 | 663 | 3.80% |
| Germany | 14981 | 616 | 4.11% |
Вывод:
США находятся в лидерах как по общему кол-ву пользователей, так и по конверсии в покупку - 2/3 пользователей приложения находятся в этой стране, из них почти 7% совершают покупки.
Узнаем, какими устройствами пользуются клиенты и какие устройства предпочитают платящие пользователи. Построим таблицу, отражающую количество пользователей и долю платящих для каждого устройства.
profiles.groupby('device').agg(
{'user_id': 'count', 'payer': ['sum', 'mean']}
).sort_values(by=('payer', 'sum'), ascending=False).applymap(lambda x: '{:.2%}'.format(x) if isinstance(x, float) else x)
| user_id | payer | ||
|---|---|---|---|
| count | sum | mean | |
| device | |||
| iPhone | 54479 | 3382 | 6.21% |
| Android | 35032 | 2050 | 5.85% |
| Mac | 30042 | 1912 | 6.36% |
| PC | 30455 | 1537 | 5.05% |
Вывод:
Распределение по устройствам входа достаточно равномерное, но лидерство с заметным отрывом у iPhone. Устройства от Apple составляют почти 60% от всей аудитории. Конверсия в покупку у них тоже чуть лучше - 6,2% и 6,3% на устройствах Apple против 5,8% и 5% на Android и PC.
Изучим рекламные источники привлечения и определим каналы, из которых пришло больше всего платящих пользователей. Построим таблицу, отражающую количество пользователей и долю платящих для каждого канала привлечения.
profiles.groupby('channel').agg(
{'user_id': 'count', 'payer': ['sum', 'mean']}
).sort_values(by=('payer', 'sum'), ascending=False).applymap(lambda x: '{:.2%}'.format(x) if isinstance(x, float) else x)
| user_id | payer | ||
|---|---|---|---|
| count | sum | mean | |
| channel | |||
| FaceBoom | 29144 | 3557 | 12.20% |
| TipTop | 19561 | 1878 | 9.60% |
| organic | 56439 | 1160 | 2.06% |
| WahooNetBanner | 8553 | 453 | 5.30% |
| AdNonSense | 3880 | 440 | 11.34% |
| RocketSuperAds | 4448 | 352 | 7.91% |
| LeapBob | 8553 | 262 | 3.06% |
| OppleCreativeMedia | 8605 | 233 | 2.71% |
| lambdaMediaAds | 2149 | 225 | 10.47% |
| YRabbit | 4312 | 165 | 3.83% |
| MediaTornado | 4364 | 156 | 3.57% |
Вывод:
Лидерами по привлечению покупающих клиентов стали соцсети FaceBoom и TipTop - от них пришло 3557 и 1878 покупателей при конверсии 12% и 9,6%, соответственно. На третьем месте бесплатная для нас "органика".
Так же стоит отметить два источника с высокой конверсией, но с меньшим кол-вом пришедших пользователей (и покупателей): AdNonSense и lambdaMediaAds. У них 11% и 10% конверсия, но от них пришло в несколько раз меньше пользователей, чем от лидеров.
Вывод по разделу:
Ключевые тезисы:
Посчитаем общую сумму расходов на маркетинг.
print('Суммарные расходы на рекламу:', round(profiles['acquisition_cost'].sum(), 2))
Суммарные расходы на рекламу: 105497.3
Выясним, сколько денег потратили на каждый источник:
costs_gr = profiles.groupby('channel')['acquisition_cost'].sum().sort_values(ascending=False).reset_index()
costs_gr['proportion'] = costs_gr['acquisition_cost'] / costs_gr['acquisition_cost'].sum()
costs_gr
| channel | acquisition_cost | proportion | |
|---|---|---|---|
| 0 | TipTop | 54751.30 | 0.518983 |
| 1 | FaceBoom | 32445.60 | 0.307549 |
| 2 | WahooNetBanner | 5151.00 | 0.048826 |
| 3 | AdNonSense | 3911.25 | 0.037074 |
| 4 | OppleCreativeMedia | 2151.25 | 0.020392 |
| 5 | RocketSuperAds | 1833.00 | 0.017375 |
| 6 | LeapBob | 1797.60 | 0.017039 |
| 7 | lambdaMediaAds | 1557.60 | 0.014764 |
| 8 | MediaTornado | 954.48 | 0.009047 |
| 9 | YRabbit | 944.22 | 0.008950 |
| 10 | organic | 0.00 | 0.000000 |
Безоговорочными лидерами по рекламному бюджету являются уже знакомые нам FaceBoom и TipTop. Вместе они съедают 82% бюджета.
В базе большое кол-во пользователей, пришедших через "органику". Учитывая цели исследования (причины провала рекламной компании), мы можем исключить "органику" из анализа, т.к. расходы на этот канал привлечения равны нулю. Дальнейшее расследование будем проводить только по коммерческим каналам.
profiles = profiles.query('channel != "organic"')
Построим визуализацию динамики изменения расходов на рекламу во времени по каждому источнику. Сделаем это одновременно в двух разрезах: по неделям и месяцам.
# для этого в исходном датасете с расходами выделим недели и месяцы из даты
costs['week'] = pd.to_datetime(costs['dt']).dt.isocalendar().week
costs['month'] = pd.to_datetime(costs['dt']).dt.month
# График по неделям
ax1 = plt.subplot(2, 2, 1)
costs.pivot_table(
index='week', columns='channel', values='costs', aggfunc='sum'
).plot(grid=True, figsize=(17, 7), ax=ax1)
plt.ylabel('CAC, $', fontsize=13)
plt.xlabel('№ недели', fontsize=13)
plt.title('Динамика САС по каналам привлечения (по неделям)' + '\n', fontsize=14)
# График по месяцам
ax2 = plt.subplot(2, 2, 2, sharey = ax1)
costs.pivot_table(
index='month', columns='channel', values='costs', aggfunc='sum'
).plot(grid=True, figsize=(18, 15), ax=ax2)
plt.ylabel('CAC, $', fontsize=13)
plt.xlabel('№ месяца', fontsize=13)
plt.title('Динамика САС по каналам привлечения (по месяцам)' + '\n', fontsize=14)
plt.show()
Вывод:
С самого начала наблюдаемого периода можно заметить рост расходов по каналам TipTop и FaceBoom. Причем если по FaceBoom они после шестого месяца в некоторой степени стабилизируются, то рост расходов по TipTop продолжается и далее заметными темпами. Итого месяцчные затраты на TipTop в наблюдаемый период выросли более чем в 4 раза - с 3000 до 13000 долларов в месяц.
Скорее всего ощутимые убытки следует искать там, где расходы также велики. Однако сейчас рано делать поспешные выводы, нужно добыть ещё информации. Для этого копнём глубже.
Посчитаем, сколько в среднем стоило привлечение одного пользователя:
print('Стоимость привлечения пользователя для всей базы:', round(profiles['acquisition_cost'].mean(), 3), '$')
Стоимость привлечения пользователя для всей базы: 1.127 $
Посчитаем, сколько в среднем стоило привлечение одного пользователя из каждого источника:
profiles.groupby('channel')['acquisition_cost'].mean().sort_values(ascending=False)
channel TipTop 2.799003 FaceBoom 1.113286 AdNonSense 1.008054 lambdaMediaAds 0.724802 WahooNetBanner 0.602245 RocketSuperAds 0.412095 OppleCreativeMedia 0.250000 YRabbit 0.218975 MediaTornado 0.218717 LeapBob 0.210172 Name: acquisition_cost, dtype: float64
Эти же два рекламных канала являются лидерами по средней стоимости привлечения одного пользователя:
TipTop - 2.8 доллара на пользователя;FaceBoom - 1.1 доллара на пользователя;AdNonSense также как и первые два перевалил через границу 1 доллара на пользователя;Остальные каналы расположились ниже на уровне среднего значения и ниже.
Чтобы понять много это или мало, нам нужно посчитать сколько покупатели приносят нам денег в обратную сторону.
Вывод по разделу:
FaceBoom и TipTop;FaceBoom и TipTop;Дополнительно:
В этом разделе проанализируем окупаемость рекламы. Сначала в общем по базе, затем и в различных разрезах. За момент анализа возьмём 1 ноября 2019 года, горизонт - 14 дней.
В некоторых графиках для лучшей читаемости будем изменять окно сглаживания - от 7 до 14 дней.
Для начала построим графики LTV, ROI и CAC для всей базы:
observation_date = datetime(2019, 11, 1).date() # момент анализа
horizon_days = 14 # горизонт анализа
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles, purchases, observation_date, horizon_days
)
plot_ltv_roi(ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days)
Наблюдения:
Чтобы разобраться в причинах, пройдём по всем доступным характеристикам пользователей — стране, источнику и устройству первого посещения.
В этом разделе проверим конверсию и удержание пользователей, а также динамику их изменения.
Посмотрим на конверсию пользователей в нескольких разрезах:
1. Общая конверсия:
dimensions = []
conversion_raw, conversion_grouped, conversion_history = get_conversion(
profiles, purchases, observation_date, horizon_days, dimensions=dimensions
)
plot_conversion(conversion_grouped, conversion_history, horizon_days, 7)
Можно сказать, что увеличением бюджета в мае-июне конверсия 14-го дня также выросла. Но этих графиков недостаточно, нужно углубляться в детали.
2. Конверсия по устройствам:
Построим графики конверсии пользователей в разрезе устройств.
Установим окно сглаживания в 14 дней
dimensions = ['device']
conversion_raw, conversion_grouped, conversion_history = get_conversion(
profiles, purchases, observation_date, horizon_days, dimensions=dimensions
)
plot_conversion(conversion_grouped, conversion_history, horizon_days, 14)
Наблюдения:
3. Конверсия по каналам привлечения:
Построим графики конверсии пользователей в разрезе каналов привлечения.
Установим окно сглаживания в 14 дней
dimensions = ['channel']
conversion_raw, conversion_grouped, conversion_history = get_conversion(
profiles, purchases, observation_date, horizon_days, dimensions=dimensions
)
plot_conversion(conversion_grouped, conversion_history, horizon_days, 14)
Наблюдения:
FaceBoomTipTop и FaceBoom в мае-июне (когда случился резкий рост бюджета) остаётся стабильной. LambdaMediaAds и AdNonSense. В остальном графики всех каналов можно назвать стабильными. У каждого свой рисунок, но из ряда вон выходящих явлений не замечено. По аналогии с конверсией изучим и удержание пользователей:
3. Конверсия по каналам привлечения:
Установим окно сглаживания в 14 дней
dimensions = []
retention_raw, retention_grouped, retention_history = get_retention(
profiles, visits, observation_date, horizon_days, dimensions=dimensions
)
plot_retention(retention_grouped, retention_history, horizon_days, 7)
График динамики удержания достаточно стабилен, но есть слабый тренд на снижение.
Как и в случае с конверсией, общих данных недостаточно. Нужно смотреть в разрезах.
2. Удержание по устройствам:
Установим окно сглаживания в 14 дней
dimensions = ['device']
retention_raw, retention_grouped, retention_history = get_retention(
profiles, visits, observation_date, horizon_days, dimensions=dimensions
)
plot_retention(retention_grouped, retention_history, horizon_days, 14)
Наблюдения:
3. Конверсия по каналам привлечения:
Установим окно сглаживания в 7 дней
dimensions = ['channel']
retention_raw, retention_grouped, retention_history = get_retention(
profiles, visits, observation_date, horizon_days, dimensions=dimensions
)
plot_retention(retention_grouped, retention_history, horizon_days, 7)
Наблюдения:
FaceBoom и AdNonSense: показатели удержания у них в 2-3 раза ниже остальных. TipTop) - держатся вместе примерно на одном уровне.FaceBoom и TipTop уже ранее обратили на себя внимание резким ростом бюджетов. В антизаслуги FaceBoom теперь можно записать и крайне низкие показатели удержания покупателей. По этому же критерию в "подозрительные" можно отнести и AdNonSense.
Проанализируем окупаемость рекламы с разбивкой по устройствам. Построим графики LTV и ROI, а также графики динамики LTV, CAC и ROI.
dimensions = ['device']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles, purchases, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
Наблюдения:
Проанализируем окупаемость рекламы с разбивкой по странам. Построим графики LTV и ROI, а также графики динамики LTV, CAC и ROI.
dimensions = ['region']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles, purchases, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
Наблюдения:
Самое интересное оказалось в странах.
Проанализируем окупаемость рекламы с разбивкой по рекламным каналам. Построим графики LTV и ROI, а также графики динамики LTV, CAC и ROI.
dimensions = ['channel']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles, purchases, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
Наблюдения:
TipTop и LambdaMediaAds;FaceBoom и AdNonSense со второго дня прирост LTV по сути прекращается. Что вполне логично, поскольку в графике удержания эти два источника показали наихудшие результаты. LambdaMediaAds нестабильностью и непредсказуемостью графика. TipTop, FaceBoom и AdNonSense не окупается. Худший результат у TipTop - чуть выше 50% на 14-й день;FaceBoom ни разу не пересекал границу окупаемости. AdNonSense поднимался выше уровня окупаемости лишь 3 раза осенью, но стабильностью не отличался. TipTop с ростом рекламного бюджета в мае-июне опустился ниже порога окупаемости, хотя изначально реклама там окупалась. TipTop - единственный канал, по которому вырос CAC.YRabbit - реклама там отлично отбивается.Промежуточный вывод:
Нам известно, что:
TipTop, FaceBoom и AdNonSense не окупается;FaceBoom и AdNonSense - худшие по удержанию; TipTop - единственный канал, по которому вырос CAC;TipTop опустился ниже порога окупаемости после увеличения бюджета;Есть предположение, что истории с TipTop и США связаны между собой. Проверим эту гипотезу.
Проведём экспресс-анализ окупаемости рекламы в нескольких разрезах. Но в этот раз сделаем это отдельно для США и отдельно для остальных стран (Европы).
Рассмотрим окупаемость в двух разрезах:
Выводы сделаем после изучения обеих групп графиков.
dimensions = ['device']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles.query('region != "United States"'), purchases, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
dimensions = ['channel']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles.query('region != "United States"'), purchases, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
Наблюдения:
AdNonSense - вновь явный аутсайдер: дороже всех стоит привлечение, и при этом не окупается.LambdaMediaAds;TipTop и FaceBoom не используются для привлечения пользователей в Европе.Рассмотрим окупаемость в двух разрезах:
Выводы также сделаем ниже, после изучения обеих групп графиков.
dimensions = ['device']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles.query('(region == "United States")'), purchases, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
dimensions = ['channel']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles.query('region == "United States"'), purchases, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
Наблюдения:
LTV на всех устройствах выглядит вновь схоже и достаточно стабилен, разве что за некоторыми исключениями.
Реклама на TipTop и FaceBoom не окупается. Однако между ними есть важное для нас отличие: ROI у TipTop до мая-июня был в плюсе;
YRabbit выглядит весьма многообещающе. Стоит обратить на это внимание маркетологов;TipTop.Наиболее важным для нас нюансом является история с ROI у TipTop. Напрашивается график окупаемости по устройствам в США:
TipTopTipTopВзглянем на них:
Без TipTop:
dimensions = ['device']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles.query('(region == "United States") and (channel != "TipTop") '), purchases, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
Только TipTop:
dimensions = ['device']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles.query('(region == "United States") and (channel == "TipTop") '), purchases, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
Наблюдения:
TipTop приносит заметно больше денег, а график динамики LTV выглядит чуть более стабильным для всех устройств. К качеству трафика вопросов нет. Тем не менее, важно понимать, что увеличение бюджета никак не сказалось на LTV - ни в минус, ни в плюс. TipTop был в ощутимом плюсе почти по всем устройствам, особенно на Android - реклама окупалась в 2.5 раза. Только ПК топтался около порога окупаемости. Подробный вывод опишем в заключительном разделе.
Ответим на поставленные в начале исследования вопросы:
Окупается ли реклама, направленная на привлечение пользователей в целом?
Если смотреть на общую картину, то рекламная стратегия компании убыточна. Однако ситуация оказывается гораздо глубже и сложнее при подробном изучении расходов и профилей пользователей в различных разрезах.
Какие устройства, страны и рекламные каналы могут оказывать негативное влияние на окупаемость рекламы?
Разные устройства и каналы привлечения могут вести себя по-разному в различных странах и сочетаниях между собой. Также стоит понимать, что в моменте неудачи по определенному рекламному каналу могут быть не связаны с ним самим непосредственно - они могут быть вызваны неудачными решениями в стратегии продвижения. Всё же можно выделить некоторые устойчивые тезисы:
FaceBoom в США и AdNonSense в Европе являются самыми дорогими по привлечению в своих регионах. Оба канала имеют проблемы с удержанием пользователей. Учитывая то, что наше приложение одинаковое для всех и у многих других сетей проблем с удержанием нет, можно предположить, что, используя эти каналы, мы просто не попадаем в нужную аудиторию. Как следствие - имеем проблемы и с окупаемостью. TipTop по результатам полугодовой рекламной кампании можно отнести к рисковому каналу привлечения, но, вероятнее всего, многое зависит от методов закупа трафика (за методами следует и цена закупа) и тонких настроек продвижения.Чем могут быть вызваны проблемы окупаемости?
Основные убытки компания понесла из-за изменения маркетинговой стратегии на рынке США. Но нельзя сказать, что это произошло непосредственно из-за увеличения объёма закупа трафика, т.к. качество привлекаемого трафика не изменилось. Скорее всего, кроме увеличения бюджета произошли и другие изменения в стратегии продвижения:
TipTop. Изначально он показывал результаты лучше, чем у других сетей. По графику роста САС видно, что увеличение объёмов закупа происходило в несколько этапов. Ошибкой было не отслеживать промежуточные результаты рекламной кампании. FaceBoom с долей 30% занимает второе место по объёму затрат на привлечение пользователей. Весь привлечённый трафик из этой соцсети не окупился. В среднем на каждом пользователе компания теряла до 20% суммы, затраченной на его привлечение. AdNonSense в Европе также повлекло убытки, но доля расходов на этот канал составляет лишь 3.7% от общего бюджета. FaceBoom и AdNonSense. Следует провести анализ аудитории каналов FaceBoom и AdNonSense на предмет соответствия портрету пользователя ProcrastinatePRO+. На основании результатов исследования принять решение о целесообразности дальнейшего продвижения через эти каналы. TipTop в мае 2019 года.